feat: more complete main window implementation - #741
Conversation
…into main-window
…into main-window
…into main-window
Resolves conflicts from ~3 years of drift. Notable resolution: keep the Container-based Qt MainWindow composition from pyapp-kit#686 (central widget swapped under a QMainWindow) and graft on the new _mgui_add_dock_widget/_mgui_add_tool_bar/_mgui_set_status_bar/ _mgui_set_menu_bar methods, instead of this branch's QBaseWidget rewrite that reimplemented the container protocol by hand. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…f layout isinstance(self, QtW.QHBoxLayout) can never be true for a backend widget; check the layout object instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- MainWindow now builds on Container: the H/VBox created by Container becomes the central widget of the IpyMainWindow grid, and container children flow into it (previously append() raised KeyError because __init__ skipped Container entirely and nothing was wired to the central cell). Container child management is routed through a _box attribute so subclasses can redirect it. - Grid uses auto/1fr templates so empty bars collapse and the central widget takes the remaining space (per the mapping discussed in pyapp-kit#601). - MenuBar/Menu implemented as a row of Dropdowns: the first entry acts as the menu title, selecting an entry fires its callback and resets the selection. Nested menus raise NotImplementedError for now. - StatusBar: added widgets tracked separately from the message label and spacer; negative insert positions append (matching Qt, where insertWidget(-1) is out-of-range); use get_running_loop() for message timeouts instead of the deprecated get_event_loop(). - set_menu_bar(None)/set_status_bar(None) no longer raise. - Fix _mgui_get_native_layout (raise -> return) and export Menu/MenuBar from magicgui.widgets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ends Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Auto-selecting the ipynb backend inside a Jupyter kernel is separable from the main-window work and changes behavior for all existing notebook users of the qt backend, so it should be its own PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GridspecLayout.__setitem__ writes the cell position into child.layout.grid_area, so the bar/dock boxes must not share a Layout instance (previously all eight ended up stacked in one cell). Also set the grid templates via the private attributes that GridspecLayout._update_layout re-applies on every assignment, so empty bars collapse and the central widget row/column gets 1fr. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Matches Qt, where a toolbar spans its area and add_spacer() pushes subsequent items to the far side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #741 +/- ##
==========================================
+ Coverage 89.52% 90.15% +0.62%
==========================================
Files 40 43 +3
Lines 4899 5314 +415
==========================================
+ Hits 4386 4791 +405
- Misses 513 523 +10 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
test_widgets.py::test_bound_callable_catches_recursion carries a FIXME saying it must run before any test swaps backends between qt and ipynb, or it stack-overflows on Windows. test_main_window.py sorted before test_widgets.py and its backend-parametrized tests swap backends, which crashed the three Windows jobs on CI. Rename the file so it collects after test_widgets.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The namespace from runpy.run_path is discarded, so widgets created by
an example are garbage-collected at an arbitrary later point during
event processing. A still-visible widget whose C++ side is deleted
mid-event-loop can receive a paintEvent during deletion, which PyQt5
escalates to a process abort:
RuntimeError: wrapped C/C++ object of type QRangeSlider has been
deleted
as seen intermittently on the windows-latest (3.12) pyqt5 job in
test_example[demo_widgets/range_slider.py] (e.g. the scheduled main
run on 2026-08-22, and both pyapp-kit#741 and pyapp-kit#742).
Close (hide) all top-level widgets first so nothing can paint, then
deleteLater() + sendPostedEvents(None, DeferredDelete) to destroy the
native widgets (and stop their timers) while hidden -- note that
processEvents() alone does NOT dispatch DeferredDelete events -- then
gc.collect() for anything left over.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The namespace from runpy.run_path is discarded, so widgets created by
an example are garbage-collected at an arbitrary later point during
event processing. A still-visible widget whose C++ side is deleted
mid-event-loop can receive a paintEvent during deletion, which PyQt5
escalates to a process abort:
RuntimeError: wrapped C/C++ object of type QRangeSlider has been
deleted
as seen intermittently on the windows-latest (3.12) pyqt5 job in
test_example[demo_widgets/range_slider.py] (e.g. the scheduled main
run on 2026-08-22, and both pyapp-kit#741 and pyapp-kit#742).
Close (hide) all top-level widgets first so nothing can paint, then
deleteLater() + sendPostedEvents(None, DeferredDelete) to destroy the
native widgets (and stop their timers) while hidden -- note that
processEvents() alone does NOT dispatch DeferredDelete events -- then
gc.collect() for anything left over.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: deterministically tear down example widgets
The namespace from runpy.run_path is discarded, so widgets created by
an example are garbage-collected at an arbitrary later point during
event processing. A still-visible widget whose C++ side is deleted
mid-event-loop can receive a paintEvent during deletion, which PyQt5
escalates to a process abort:
RuntimeError: wrapped C/C++ object of type QRangeSlider has been
deleted
as seen intermittently on the windows-latest (3.12) pyqt5 job in
test_example[demo_widgets/range_slider.py] (e.g. the scheduled main
run on 2026-08-22, and both #741 and #742).
Close (hide) all top-level widgets first so nothing can paint, then
deleteLater() + sendPostedEvents(None, DeferredDelete) to destroy the
native widgets (and stop their timers) while hidden -- note that
processEvents() alone does NOT dispatch DeferredDelete events -- then
gc.collect() for anything left over.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: allow pytest-qt >=4.5 everywhere except PySide2
pytest-qt 4.4.0 is incompatible with recent PySide6: qtbot.waitSignal
fails with 'SystemError: <method disconnect of SignalInstance> returned
a result with an exception set' and a TimeoutError in
test_parent_changed (reproduced locally with PySide6 6.11.2 +
pytest-qt 4.4.0; fixed by pytest-qt 4.5.0, see pytest-dev/pytest-qt#552).
This is what has been failing every pyside6 CI job (and the scheduled
runs on main) since at least mid-July.
pytest-qt 4.5 dropped PySide2, so the pyside2 dependency group keeps
the 4.4.0 pin via a separate test-qt-legacy group, declared as a uv
conflict so universal resolution still succeeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* build: emit core metadata 2.4 until twine accepts 2.5
hatchling 1.32.0 (2026-08-11) bumped the default core metadata version
to 2.5, which the twine run by build-and-inspect-python-package rejects
('InvalidDistribution: 2.5 is not a valid metadata version'). Nobody
noticed because build-and-inspect needs the test job, which has been
red since mid-July (see previous commit). Verified locally: with this
setting both the wheel and sdist emit Metadata-Version 2.4 and pass
'twine check --strict'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style(pre-commit.ci): auto fixes [...]
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
# Conflicts: # src/magicgui/backends/_ipynb/widgets.py # src/magicgui/widgets/bases/_toolbar.py
|
Okay merged |
|
Thanks a lot for coming back to this @larsoner, I'm more or less happy to merge this. Strictly new feature, shouldn't affect any existing behavior (unless someone is creating a concrete local claude review did surface these two small bugs... do you agree that they should be fixed? Or is it wrong?
|
- Qt MenuBar._mgui_clear was a docstring-only stub; now calls QMenuBar.clear() - MenuBarWidget.clear()/MenuWidget.clear() now also clear the frontend _menus dict so menu_bar['File'] no longer returns a stale menu after clear() - test_window.py asserts both (empty native actions/children, KeyError lookup) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012eVFkZ4Tnh6ZABSqb17H1K
|
Claude was right! Fixes pushed |
|
thanks! |
Closes #601
Closes #604
So... the "next week" I promised in 2023 has finally arrived 😅 This PR has the 43 commits from that PR, plus what Claude Fable found while reviewing (below is its summary). I reviewed the commits and they make sense to me. Notably I removed the backend autodetection since we should probably scope / implement that separately. (Automatic IPython importing can be problematic, and maybe it should be opt-in, etc.)
Before the Fable fixes:
After:
Qt (I'm on macOS so the menu isn't captured in-window):
main(~3 yrs of drift)MainWindowwork as a realContainer: children now flow into the grid's central cell (previouslymain.append(...)raisedKeyError).MenuBar/Menuas a row of Dropdowns per the MainWindow for ipywidgets #601 discussion (first entry is the title placeholder; selecting an entry fires its callback and resets); nested submenus raiseNotImplementedErrorfor now.Layoutinstance (they shared one, soGridspecLayout.__setitem__stacked all eight into a single cell), and the grid templates are set via the attributes_update_layout()re-applies, so empty areas collapse and the central widget gets1fr.StatusBar: added widgets are tracked separately from the message label and spacer, negative insert positions append (matching Qt), and message timeouts useget_running_loop()instead of the deprecatedget_event_loop().set_menu_bar(None)/set_status_bar(None)no longer crash in the ipynb backend.main: QtContainer/Dialogreportedlayout == "vertical"regardless of orientation (isinstance(self, QHBoxLayout)can never be true for a backend widget).Menu/MenuBarfrommagicgui.widgets, and fixed araise self._ipywidget→returntypo in_mgui_get_native_layout.example.pyintodocs/examples/applications/main_window.py._choose_backend()jupyter auto-detection for now — it's separable and changes default-backend behavior for all users, so it can be its own PR.